Skip to content

Fix Cosmos DB pagination for backwards compatibility#1594

Open
MarcAstr0 wants to merge 5 commits intoboostercloud:mainfrom
Optum:fix/cosmos-pagination
Open

Fix Cosmos DB pagination for backwards compatibility#1594
MarcAstr0 wants to merge 5 commits intoboostercloud:mainfrom
Optum:fix/cosmos-pagination

Conversation

@MarcAstr0
Copy link
Collaborator

@MarcAstr0 MarcAstr0 commented Mar 2, 2026

Description

v3.4.2 (see #1587) introduced continuation-token-based pagination for the Azure provider. That change replaced the numeric id in the response cursor with a continuationToken. For backwards compatibility, OFFSET-based pagination could still be forced by sending a numeric id in the afterCursor property of the GraphQL request (e.g., '0' for the first page). However, this broke existing frontends that didn't force OFFSET-based pagination and expected a numeric id in the cursor. This PR restores backwards compatibility by always returning a numeric cursor id (indicating the cursor's position) alongside the optional continuationToken.

Changes

  • Refactor query-helper.ts to always return a numeric id in the cursor for backwards compatibility

Checks

  • Project Builds
  • Project passes tests and checks
  • Updated documentation accordingly

@what-the-diff
Copy link

what-the-diff bot commented Mar 2, 2026

PR Summary

  • Addition of a new JSON file
    A new file named fix-cosmos-pagination_2026-02-27-21-10.json was added, which addresses issues with database pagination. This was classified as a minor fix for the core package of our application.

  • Modifications to query-helper.ts

    • Introduction of a new numerical value: Establishes a default page size of 100 entries.
    • Reworked pagination mechanics: Now, our database interactions have been improved to better manage data pagination, especially in terms of consistency and continuity.
    • Adjusted legacy querying system: Older methods of querying data are now more accommodating of how much data is shown on each page while retaining their former functionality.
    • Updated how cursor IDs are calculated: Cursor ID, which is used in moving from one page of data to another, is now based on an effective limit to ensure consistent navigation across different pages of data.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Restores backward compatibility for Azure/CosmosDB pagination by ensuring GraphQL cursors always include a numeric id while still supporting continuation-token pagination.

Changes:

  • Add a default page size and revise Cosmos feed options to better support continuation tokens.
  • Always include a numeric cursor.id (and optionally cursor.continuationToken) in paginated results.
  • Update the monorepo lockfile and add a Rush change file for a patch release.

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 5 comments.

File Description
packages/framework-provider-azure/src/helpers/query-helper.ts Adjusts pagination logic to return a numeric cursor id for backward compatibility and refines continuation-token/legacy OFFSET behavior.
common/config/rush/pnpm-lock.yaml Updates workspace specifiers and dependency resolutions in the pnpm lockfile.
common/changes/@boostercloud/framework-core/fix-cosmos-pagination_2026-02-27-21-10.json Adds a Rush change file to drive release/versioning metadata for the change.
Files not reviewed (1)
  • common/config/rush/pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +109 to +112
// cursor.id advances by the page size (limit) to maintain consistent page-based offsets
// that frontends rely on (e.g., limit=5 produces cursors 5, 10 ,15, ...)
const previousOffset = afterCursor?.id ? parseInt(afterCursor.id) : 0
const effectiveLimit = limit ?? DEFAULT_PAGE_SIZE
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previousOffset is computed with parseInt(afterCursor.id) without guarding for non-numeric values. If a client sends an unexpected/non-numeric id, this will become NaN and you’ll end up returning cursor.id: "NaN" (and also advance offsets incorrectly). Consider reusing the numeric check you already do for hasLegacyCursor (or fallback to 0 when Number.isNaN(parsed)), so cursor IDs are always valid numeric strings.

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +82
// Always set maxItemCount when limit is provided or when using continuation token
if (limit || afterCursor?.continuationToken) {
feedOptions.maxItemCount = limit ?? DEFAULT_PAGE_SIZE
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When paginatedVersion is true and limit is omitted on the first page, effectiveLimit defaults to DEFAULT_PAGE_SIZE for the numeric cursor, but feedOptions.maxItemCount is not set (because there is no limit and no afterCursor.continuationToken). Cosmos may then return an arbitrary page size, making the returned cursor.id inconsistent with the number of items actually returned and potentially breaking the “id indicates position/page” assumption. Consider always setting feedOptions.maxItemCount to limit ?? DEFAULT_PAGE_SIZE whenever the continuation-token path is used (not only once a continuation token exists).

Suggested change
// Always set maxItemCount when limit is provided or when using continuation token
if (limit || afterCursor?.continuationToken) {
feedOptions.maxItemCount = limit ?? DEFAULT_PAGE_SIZE
}
// Always set maxItemCount for Cosmos pagination (defaulting when limit is omitted)
feedOptions.maxItemCount = limit ?? DEFAULT_PAGE_SIZE

Copilot uses AI. Check for mistakes.
Comment on lines 114 to +118
let cursor: Record<string, string> | undefined
if (continuationToken) {
cursor = { continuationToken }
cursor = { continuationToken, id: (previousOffset + effectiveLimit).toString() }
} else if (finalResources.length > 0) {
const currentOffset = afterCursor?.id && !isNaN(parseInt(afterCursor.id)) ? parseInt(afterCursor.id) : 0
cursor = { id: (currentOffset + finalResources.length).toString() } // Use the length of the results to calculate the next id
cursor = { id: (previousOffset + effectiveLimit).toString() }
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The continuation-token pagination branch now returns a composite cursor ({ continuationToken, id }) and computes id using effectiveLimit. There are existing unit tests for search() in this package, but none appear to cover the continuation-token path (e.g., asserting maxItemCount/continuationToken usage and the returned cursor shape). Please add/update tests to cover: (1) first page continuation-token pagination (no afterCursor), (2) subsequent page with { continuationToken, id }, and (3) behavior when limit is omitted (default page size).

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +7
{
"packageName": "@boostercloud/framework-core",
"comment": "Fix pagination issues with Cosmos DB",
"type": "patch"
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change file declares a patch for @boostercloud/framework-core, but the functional change in this PR is in @boostercloud/framework-provider-azure (Cosmos pagination logic). With Rush change files, this likely means the Azure provider won’t get a version bump/release containing the fix. Consider generating the change file for @boostercloud/framework-provider-azure (or whichever package actually ships query-helper.ts).

Copilot uses AI. Check for mistakes.
Comment on lines +5170 to 5177
[email protected]:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [email protected]
hasBin: true

[email protected]:
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
engines: {node: 20 || >=22}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lockfile introduces [email protected], which is marked as deprecated due to “widely publicized security vulnerabilities” in its own metadata. If possible, consider updating the dependency chain so a supported glob version is used (or confirm why this downgrade is required), since this can affect build tooling security posture.

Suggested change
[email protected]:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [email protected]
hasBin: true
[email protected]:
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
engines: {node: 20 || >=22}
hasBin: true

Copilot uses AI. Check for mistakes.
@MarcAstr0
Copy link
Collaborator Author

/integration sha=04d10e1

@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2026

⌛ Integration tests are running...

Check their status here 👈

@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2026

❌ Oh no! Integration tests have failed

@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2026

⌛ Integration tests are running...

Check their status here 👈

@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2026

❌ Oh no! Integration tests have failed

@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2026

⌛ Integration tests are running...

Check their status here 👈

@github-actions
Copy link
Contributor

github-actions bot commented Mar 2, 2026

❌ Oh no! Integration tests have failed

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

⌛ Integration tests are running...

Check their status here 👈

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

❌ Oh no! Integration tests have failed

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

⌛ Integration tests are running...

Check their status here 👈

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

❌ Oh no! Integration tests have failed

@MarcAstr0
Copy link
Collaborator Author

/integration sha=589add3

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

⌛ Integration tests are running...

Check their status here 👈

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

✅ Integration tests have finished successfully!

Copy link
Contributor

@alvaroloes alvaroloes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex: I found a backward-compatibility regression in the new numeric cursor semantics. When limit is omitted, the implementation now treats the default page size as the cursor position, so a one-item page can emit { id: "100" }. A legacy client that feeds that numeric cursor back into the next request will skip rows instead of continuing from the next consumed item.

}
}
if (process.env.TESTED_PROVIDER === 'AZURE' || process.env.TESTED_PROVIDER === 'LOCAL') {
expect(cartShippingAddress.cursor.id).to.equal('100')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex: This expectation is locking in the regression instead of the backward-compatibility guarantee from the PR description. For a paginated query that returns a single item with no explicit limit, the numeric cursor should still represent the next consumed position ("1"), not the default page size ("100"). Otherwise a legacy client that reuses { id: "100" } on the next request will skip 99 unseen rows.

@alvaroloes
Copy link
Contributor

Claude: Code Review — Fix Cosmos DB pagination for backwards compatibility

He revisado los 4 ficheros cambiados en este PR. Análisis completo:


Contexto y lógica del cambio

El PR #1587 introdujo paginación basada en continuationToken para Azure/CosmosDB, eliminando el id numérico del cursor. Esto rompió frontends existentes que esperaban un id numérico. Este PR restaura la compatibilidad añadiendo siempre un id numérico al cursor. El enfoque es correcto en concepto.


✅ Lo que está bien

  • La idea de devolver siempre cursor.id (posición numérica) junto con el continuationToken optativo es el enfoque correcto para mantener retrocompatibilidad sin romper la nueva funcionalidad.
  • Introducir DEFAULT_PAGE_SIZE = 100 es una buena práctica para evitar que el SDK de Cosmos devuelva tamaños de página no deterministas.
  • La separación entre el path de continuationToken y el path legacy OFFSET sigue siendo clara.
  • Los tests de integración (commit 589add3) se actualizaron para reflejar el nuevo comportamiento del cursor.

🔴 Problemas que bloquean el merge

1. parseInt(afterCursor.id) sin guard de NaN — puede producir cursores corruptos

const previousOffset = afterCursor?.id ? parseInt(afterCursor.id) : 0

Si un cliente envía un afterCursor.id no numérico (por ejemplo un cursor antiguo en formato string no-numérico, o un error del cliente), parseInt devuelve NaN. El resultado es:

  • cursor.id en la respuesta vale "NaN" — string inválido que llega al cliente
  • previousOffset + effectiveLimit = NaN + 100 = NaN — corrupción silenciosa

El código ya tiene esta guardia en otro lugar (!isNaN(parseInt(afterCursor.id))) en la rama legacy, pero no en la rama nueva. Solución:

const parsedId = afterCursor?.id ? parseInt(afterCursor.id, 10) : NaN
const previousOffset = Number.isFinite(parsedId) ? parsedId : 0

2. cursor.id avanza por effectiveLimit en lugar de por el número real de items devueltos

// path con continuationToken:
cursor = { continuationToken, id: (previousOffset + effectiveLimit).toString() }

// path sin continuationToken (no legacy):
cursor = { id: (previousOffset + effectiveLimit).toString() }

Cosmos DB no garantiza que devuelva exactamente maxItemCount items en cada página. Puede devolver menos si:

  • Es la última página (parcialmente llena)
  • Cosmos decide hacer un "fan-out" en particiones y devuelve menos de lo solicitado
  • Se aplican filtros post-query

Si un cliente que solo usa cursor.id (ignorando continuationToken) hace la siguiente petición con afterCursor: { id: "100" } cuando solo se devolvieron 73 items, se saltará 27 registros.

La rama legacy del código antiguo (currentOffset + finalResources.length) era más correcta para ese caso. El fix regresa a effectiveLimit en ambos paths, lo que puede introducir data skipping peor que el bug original.

Recomendación: en el path de continuationToken, usar effectiveLimit es aceptable (el cliente debería usar el token), pero en el path sin continuationToken se debería usar finalResources.length:

} else if (finalResources.length > 0) {
  cursor = { id: (previousOffset + finalResources.length).toString() }
}

3. feedOptions.maxItemCount no se configura en la primera página del path de continuationToken cuando no hay limit

if (limit || afterCursor?.continuationToken) {
  feedOptions.maxItemCount = limit ?? DEFAULT_PAGE_SIZE
}

En la primera llamada a una query paginada con continuationToken (paginatedVersion: true, sin limit, sin afterCursor): feedOptions.maxItemCount no se establece. Cosmos devolverá un número arbitrario de items (típicamente 100, pero no garantizado), y el cursor.id calculado con effectiveLimit = 100 puede no coincidir con el número real devuelto.

Esto hace que el cursor.id sea un mentiroso desde la primera página. Solución simple y ya sugerida por Copilot:

// Siempre establecer maxItemCount en el path paginado
feedOptions.maxItemCount = limit ?? DEFAULT_PAGE_SIZE

🟡 Observaciones importantes

4. Rush change file declara framework-core en lugar de framework-provider-azure

{ "packageName": "@boostercloud/framework-core", ... }

El código que cambia está en packages/framework-provider-azure/src/helpers/query-helper.ts. Con Rush, el change file controla qué paquete recibe un version bump y aparece en el CHANGELOG. Si este change file va a framework-core, el fix nunca aparece en el changelog ni en la versión publicada de framework-provider-azure.

Los usuarios que actualicen framework-provider-azure para obtener el fix de paginación no verán nada en su CHANGELOG. Esto es un problema de trazabilidad importante para los consumidores del paquete.

Recomendación: Generar el change file con rush change --target-branch main seleccionando @boostercloud/framework-provider-azure.


5. Tests de integración fallan 4 veces antes de pasar — señal de flakiness

El historial muestra 4 rondas de "Integration tests failed" antes de que pasen con el commit 589add3. Esto puede indicar:

  • Flakiness en los tests de integración (condición de carrera, timing)
  • O que el fix de 589add3 corrigió algo real en los tests

Si es flakiness, debería abrirse un issue separado porque tests flaky en CI reducen la confianza en todos los PRs.


6. No hay tests unitarios para los nuevos paths

Las observaciones de Copilot ya señalan esto: no hay tests unitarios que cubran:

  • Primera página con continuation-token (sin afterCursor)
  • Página subsiguiente con { continuationToken, id }
  • Comportamiento cuando limit está ausente (DEFAULT_PAGE_SIZE)
  • Validación de que cursor.id es siempre un número válido

Los tests de integración son lentos y caros para iterar sobre lógica de paginación. Los tests unitarios de query-helper.ts deberían cubrir estos casos.


7. parseInt sin radix explícito — code style minor

parseInt(afterCursor.id)  // sin radix

Aunque en la práctica los IDs son siempre decimales, es buena práctica usar parseInt(afterCursor.id, 10) para ser explícito y evitar comportamiento inesperado con strings como "0x1f".


Veredicto

Request Changes. Los puntos 1 (NaN sin guardia), 2 (avance por effectiveLimit en lugar de items reales en el path sin token) y 3 (maxItemCount no siempre configurado) son bugs lógicos que pueden producir paginación incorrecta en producción. El punto 4 (change file en el paquete incorrecto) es un problema de release management que impide que el fix aparezca en el paquete correcto.

@MarcAstr0
Copy link
Collaborator Author

/integration sha=50d87eb

@github-actions
Copy link
Contributor

⌛ Integration tests are running...

Check their status here 👈

@github-actions
Copy link
Contributor

✅ Integration tests have finished successfully!

@MarcAstr0
Copy link
Collaborator Author

@alvaroloes implemented some of the suggestions in commit 50d87eb. Regarding the second one: The original code (commit 44045a7, before PR #1587) was:

cursor: { id: ((limit ? limit : 1) + (afterCursor?.id ? parseInt(afterCursor?.id) : 0)).toString() }

This always advanced by limit, not by actual result count. PR #1587 (cbac6d6) changed it to finalResources.length, which is what broke frontends. Our fix restores the original semantic. The reviewer's suggestion to use finalResources.length in the no-token path would actually reintroduce the broken behavior from #1587.

@MarcAstr0 MarcAstr0 requested a review from alvaroloes March 13, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants